aboutsummaryrefslogtreecommitdiff
path: root/src/routes/projects/[id]/+page.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes/projects/[id]/+page.svelte')
-rw-r--r--src/routes/projects/[id]/+page.svelte395
1 files changed, 395 insertions, 0 deletions
diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte
new file mode 100644
index 0000000..d9747a9
--- /dev/null
+++ b/src/routes/projects/[id]/+page.svelte
@@ -0,0 +1,395 @@
+<script lang="ts">
+ import { enhance } from '$app/forms';
+ import {
+ formatFrequency,
+ parseIntervalToDays,
+ STATUS_LABELS,
+ type RoutineState,
+ } from '$lib/shared/routine-metrics';
+
+ let { data, form } = $props();
+
+ let name = $state('');
+ let frequency = $state('');
+ let editingId = $state<number | null>(null);
+ let editName = $state('');
+ let editFrequency = $state('');
+
+ const frequencyDays = $derived(parseIntervalToDays(frequency));
+ const editFrequencyDays = $derived(parseIntervalToDays(editFrequency));
+
+ const dateFormatter = new Intl.DateTimeFormat('fr-FR', {
+ day: 'numeric',
+ month: 'short',
+ });
+
+ function sorted(routines: RoutineState[]): RoutineState[] {
+ return [...routines].sort(
+ (a, b) => b.overdueRatio - a.overdueRatio || a.name.localeCompare(b.name),
+ );
+ }
+
+ function startEdit(routine: RoutineState) {
+ editingId = routine.id;
+ editName = routine.name;
+ editFrequency = `${routine.frequencyDays}d`;
+ }
+
+ function scoreClass(score: number): string {
+ if (score >= 80) return 'score score--good';
+ if (score >= 50) return 'score score--mid';
+ return 'score score--bad';
+ }
+
+ function statusClass(status: RoutineState['status']): string {
+ if (status === 'ok') return 'status status--ok';
+ if (status === 'due') return 'status status--due';
+ if (status === 'late') return 'status status--late';
+ return 'status status--very-late';
+ }
+</script>
+
+<main>
+ <header class="page-header">
+ <h1>{data.project.name}</h1>
+ <span class={scoreClass(data.healthScore)}>{data.healthScore}</span>
+ </header>
+
+ <form
+ class="cat-add-bar"
+ method="POST"
+ action="?/addRoutine"
+ use:enhance={() => {
+ return async ({ update }) => {
+ name = '';
+ frequency = '';
+ await update({ reset: false });
+ };
+ }}
+ >
+ <input
+ class="form-input"
+ type="text"
+ name="name"
+ bind:value={name}
+ placeholder="Nouvelle routine..."
+ autocomplete="off"
+ />
+ <input
+ class="form-input frequency-input"
+ class:input-invalid={frequency !== '' && frequencyDays === null}
+ type="text"
+ name="frequencyDays"
+ bind:value={frequency}
+ placeholder="2w"
+ autocomplete="off"
+ aria-label="Intervalle (ex: 1d, 2w, 3m)"
+ />
+ <button
+ class="btn-accent"
+ type="submit"
+ disabled={!name.trim() || frequencyDays === null}
+ aria-label="Ajouter"
+ >
+ +
+ </button>
+ </form>
+
+ {#if frequency !== '' && frequencyDays === null}
+ <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p>
+ {/if}
+
+ {#if form?.error}
+ <p class="error">{form.error}</p>
+ {/if}
+
+ <ul class="item-list">
+ {#each sorted(data.routines) as routine (routine.id)}
+ <li class="item-row item-row--card routine-row">
+ {#if editingId === routine.id}
+ <form
+ class="edit-form"
+ method="POST"
+ action="?/editRoutine"
+ use:enhance={() => {
+ return async ({ result, update }) => {
+ if (result.type === 'failure') {
+ // Keep the edit form open so the error is visible.
+ await update({ reset: false });
+ return;
+ }
+ editingId = null;
+ await update({ reset: false });
+ };
+ }}
+ >
+ {#if form?.error}
+ <p class="error edit-error">{form.error}</p>
+ {/if}
+ <input type="hidden" name="id" value={routine.id} />
+ <input
+ class="form-input"
+ type="text"
+ name="name"
+ bind:value={editName}
+ />
+ <input
+ class="form-input frequency-input"
+ class:input-invalid={editFrequency !== '' &&
+ editFrequencyDays === null}
+ type="text"
+ name="frequencyDays"
+ bind:value={editFrequency}
+ placeholder="2w"
+ autocomplete="off"
+ aria-label="Intervalle (ex: 1d, 2w, 3m)"
+ />
+ <button
+ class="btn-primary"
+ type="submit"
+ disabled={!editName.trim() || editFrequencyDays === null}
+ >
+ OK
+ </button>
+ <button
+ class="btn-ghost"
+ type="button"
+ onclick={() => (editingId = null)}>✕</button
+ >
+ </form>
+ {:else}
+ <div class="routine-info">
+ <span class="routine-name">{routine.name}</span>
+ <span class="routine-meta">
+ {formatFrequency(routine.frequencyDays)}
+ {#if routine.lastDoneAt}
+ · fait le {dateFormatter.format(new Date(routine.lastDoneAt))}
+ {:else}
+ · jamais fait
+ {/if}
+ </span>
+ </div>
+ <span class={statusClass(routine.status)}>
+ {STATUS_LABELS[routine.status]}
+ {#if routine.overdueDays > 0}({routine.overdueDays}
+ j){/if}
+ </span>
+ <form method="POST" action="?/complete" use:enhance>
+ <input type="hidden" name="id" value={routine.id} />
+ <button class="btn-done" type="submit">Fait</button>
+ </form>
+ <button
+ class="btn-ghost"
+ type="button"
+ aria-label="Modifier"
+ onclick={() => startEdit(routine)}>✎</button
+ >
+ <form method="POST" action="?/archiveRoutine" use:enhance>
+ <input type="hidden" name="id" value={routine.id} />
+ <button class="btn-ghost" type="submit" aria-label="Archiver">
+ &times;
+ </button>
+ </form>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+
+ {#if data.routines.length === 0}
+ <p class="empty">Aucune routine</p>
+ {/if}
+
+ <footer class="page-footer">
+ <form method="POST" action="?/archiveProject" use:enhance>
+ <button class="btn-archive" type="submit">Archiver le projet</button>
+ </form>
+ </footer>
+</main>
+
+<style>
+ .page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.25rem 0;
+ }
+
+ .routine-row {
+ flex-wrap: wrap;
+ padding: 0.75rem 1rem;
+ }
+
+ .routine-info {
+ flex-grow: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ line-height: 1.2;
+ }
+
+ .routine-name {
+ font-weight: 500;
+ }
+
+ .routine-meta {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ }
+
+ .edit-form {
+ display: flex;
+ flex-grow: 1;
+ gap: 0.5rem;
+ align-items: center;
+ flex-wrap: wrap;
+ }
+
+ .edit-error {
+ width: 100%;
+ }
+
+ .edit-form .form-input {
+ flex: 1;
+ padding: 0.5rem 0.75rem;
+ }
+
+ .frequency-input {
+ width: 4.5rem;
+ flex-shrink: 0;
+ }
+
+ .input-invalid {
+ border-color: var(--danger);
+ }
+
+ .status {
+ flex-shrink: 0;
+ padding: 0.25rem 0.5rem;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ white-space: nowrap;
+ }
+
+ .status--ok {
+ background: var(--success);
+ color: var(--white);
+ }
+
+ .status--due {
+ background: var(--shadow-accent);
+ color: var(--accent);
+ }
+
+ .status--late {
+ background: var(--accent);
+ color: var(--white);
+ }
+
+ .status--very-late {
+ background: var(--danger);
+ color: var(--white);
+ }
+
+ .btn-done {
+ flex-shrink: 0;
+ padding: 0.5rem 0.875rem;
+ border: none;
+ border-radius: 0.625rem;
+ background: var(--success);
+ color: var(--white);
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all 0.2s ease;
+ }
+
+ .btn-done:hover {
+ filter: brightness(1.05);
+ transform: translateY(-1px);
+ }
+
+ .score {
+ flex-shrink: 0;
+ min-width: 2.75rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 9999px;
+ text-align: center;
+ font-size: 0.875rem;
+ font-weight: 700;
+ color: var(--white);
+ }
+
+ .score--good {
+ background: var(--success);
+ }
+
+ .score--mid {
+ background: var(--accent);
+ }
+
+ .score--bad {
+ background: var(--danger);
+ }
+
+ .cat-add-bar {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.75rem 0;
+ }
+
+ .cat-add-bar input:first-child {
+ flex: 1;
+ background: var(--surface);
+ min-width: 0;
+ }
+
+ .btn-accent {
+ width: 3rem;
+ height: 3rem;
+ flex-shrink: 0;
+ padding: 0;
+ border: none;
+ border-radius: 0.75rem;
+ background: var(--accent);
+ color: var(--white);
+ font-size: 1.25rem;
+ font-weight: 500;
+ font-family: inherit;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ box-shadow: 0 1px 2px var(--shadow);
+ }
+
+ .btn-accent:hover {
+ background: var(--accent-hover);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 8px var(--shadow-accent-lg);
+ }
+
+ .page-footer {
+ margin-top: 2rem;
+ display: flex;
+ justify-content: center;
+ }
+
+ .btn-archive {
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ text-decoration: underline;
+ cursor: pointer;
+ }
+
+ .btn-archive:hover {
+ color: var(--danger);
+ }
+
+ .empty {
+ margin-top: 1rem;
+ padding: 2rem;
+ }
+</style>